昨天雖然寫了 os.File.Write,但作業系統很聰明,會先把資料快取在 Page Cache 裡,不會馬上寫進磁碟。如果這時候當機,資料照樣不見。
今天來補強制 fsync 機制,先實作 Redis 常見的 everysec(每秒非同步 fsync) 策略,在效能和資料安全之間抓一個比較實際的平衡。
Redis 提供了 appendfsync 配置,支持以下三種 fsync策略:
AOF appendfsync 策略
├── always(每次 fsync)
│ ├── 原理:每次寫入都調用 fsync
│ └── 特點:最安全,但磁碟 I/O 慢、效能極差
├── everysec(每秒fsync)
│ ├── 原理:背景 goroutine 每秒調用一次 fsync
│ └── 特點:效能高,斷電最多只遺失 1 秒資料,業界首選
└── no(系統控制fsync)
├── 原理:不主動調用 fsync,由 OS 決定何時fsync
└── 特點:效能最高,但當機時可能遺失高達 30 秒資料
always:每次客戶端發送 SET,伺服器都要等待磁碟磁頭寫完才能回覆,吞吐量會降到每秒幾百次。no:效能極佳,但當機時丟失的資料不可控。everysec:main thread 先把命令寫到 Page Cache,背景 goroutine 每秒調用一次 fsync。這樣寫入延遲比較低,最壞情況通常是遺失最後 1 秒左右的資料。我在 code/db/aof.go 裡實作了這個機制。
當 AofLogger 被初始化時,我們隨即啟動一個背景 goroutine:
func NewAofLogger(filename string, dbEngine *DB) (*AofLogger, error) {
// ... 開啟檔案 ...
logger := &AofLogger{
file: file,
filename: filename,
dbEngine: dbEngine,
stopSync: make(chan struct{}),
}
// 啟動背景 goroutine:每秒進行一次 fsync
go logger.backgroundSync()
return logger, nil
}
這裡用 Go 的 time.Ticker,每隔 1 秒喚醒一次背景 goroutine,拿鎖後呼叫底層的 file.Sync()(本質上就是 fsync 系統呼叫),把 Page Cache 裡的資料刷到磁碟:
func (a *AofLogger) backgroundSync() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.mu.Lock()
if a.file != nil {
_ = a.file.Sync() // 強制作業系統將緩衝區資料刷入物理磁碟
}
a.mu.Unlock()
case <-a.stopSync:
return // 接收到關閉訊號,安全退出
}
}
}
寫這個背景 fsync 的時候,我一開始忘了處理伺服器Graceful Shutdown,結果主程序一結束,還沒 fsync 的資料就被拋棄了。才意識到要在 Close 裡面發信號給 channel,最後再 Sync 一次。
當伺服器關閉時,我要先通知背景 goroutine 退出,隨後進行最後一次 Sync() 確保所有剩餘資料都已 sync 到磁碟,最後才關閉檔案:
func (a *AofLogger) Close() error {
close(a.stopSync) // 通知背景 goroutine 退出
a.mu.Lock()
defer mu.Unlock()
var err error
if a.file != nil {
_ = a.file.Sync() // 最後一次 fsync
err = a.file.Close()
a.file = nil
}
return err
}
我們來實測一下資料恢復。首先啟動 Server 並寫入一筆資料:
# 終端機 1
go run ./code/main.go
# 終端機 2
printf "*3\r\n\$3\r\nSET\r\n\$7\r\nrecover\r\n\$7\r\nsuccess\r\n" | nc localhost 6379
# 預期回覆:+OK
現在,回到終端機 1,直接按 Ctrl+C 強制關閉 Server!
接著,再次啟動 Server:
# 終端機 1
go run ./code/main.go
# 啟動時你會看到 log 說「正在載入 appendonly.aof...」
再開終端機去查剛剛那筆資料:
# 終端機 2
printf "*2\r\n\$3\r\nGET\r\n\$7\r\nrecover\r\n" | nc localhost 6379
# 預期回覆:$7\r\nsuccess\r\n
資料還在,代表 AOF replay 至少有把這筆狀態救回來。
今天用 Go 的 Ticker 把每秒背景 fsync 做起來,AOF 這塊終於比較像樣了。這邊遇到最大的雷是 fsync的時候如果寫錯長度,整個 aof 檔會爛掉,然後下次重啟 parse 直接炸開,只能乖乖加 fsync 跟 checksum 來防呆。
但日誌一直追加也不是辦法,檔案遲早會膨脹。明天來寫背景 AOF 重寫(BGREWRITEAOF),這塊應該很多細節要處理,大家明天見!